/**
 * JSON Tree library (a part of jsonTreeViewer)
 * http://github.com/summerstyle/jsonTreeViewer
 *
 * Copyright 2017 Vera Lobacheva (http://iamvera.com)
 * Released under the MIT license (LICENSE.txt)
 */

const jsonTree = (function () {
  /* ---------- Utilities ---------- */
  var utils = {

    /*
       * Returns js-"class" of value
       *
       * @param val {any type} - value
       * @returns {string} - for example, "[object Function]"
       */
    getClass(val) {
      return Object.prototype.toString.call(val);
    },

    /**
       * Checks for a type of value (for valid JSON data types).
       * In other cases - throws an exception
       *
       * @param val {any type} - the value for new node
       * @returns {string} ("object" | "array" | "null" | "boolean" | "number" | "string")
       */
    getType(val) {
      if (val === null) {
        return 'null';
      }

      switch (typeof val) {
        case 'number':
          return 'number';

        case 'string':
          return 'string';

        case 'boolean':
          return 'boolean';
      }

      switch (utils.getClass(val)) {
        case '[object Array]':
          return 'array';

        case '[object Object]':
          return 'object';
      }

      throw new Error(`Bad type: ${utils.getClass(val)}`);
    },

    /**
       * Applies for each item of list some function
       * and checks for last element of the list
       *
       * @param obj {Object | Array} - a list or a dict with child nodes
       * @param func {Function} - the function for each item
       */
    forEachNode(obj, func) {
      const type = utils.getType(obj);
      let isLast;

      switch (type) {
        case 'array':
          isLast = obj.length - 1;

          obj.forEach((item, i) => {
            func(i, item, i === isLast);
          });

          break;

        case 'object':
          var keys = Object.keys(obj).sort();

          isLast = keys.length - 1;

          keys.forEach((item, i) => {
            func(item, obj[item], i === isLast);
          });

          break;
      }
    },

    /**
       * Implements the kind of an inheritance by
       * using parent prototype and
       * creating intermediate constructor
       *
       * @param Child {Function} - a child constructor
       * @param Parent {Function} - a parent constructor
       */
    inherits: (function () {
      const F = function () {};

      return function (Child, Parent) {
        F.prototype = Parent.prototype;
        Child.prototype = new F();
        Child.prototype.constructor = Child;
      };
    }()),

    /*
       * Checks for a valid type of root node*
       *
       * @param {any type} jsonObj - a value for root node
       * @returns {boolean} - true for an object or an array, false otherwise
       */
    isValidRoot(jsonObj) {
      switch (utils.getType(jsonObj)) {
        case 'object':
        case 'array':
          return true;
        default:
          return false;
      }
    },

    /**
       * Extends some object
       */
    extend(targetObj, sourceObj) {
      for (const prop in sourceObj) {
        if (sourceObj.hasOwnProperty(prop)) {
          targetObj[prop] = sourceObj[prop];
        }
      }
    },
  };


  /* ---------- Node constructors ---------- */

  /**
   * The factory for creating nodes of defined type.
   *
   * ~~~ Node ~~~ is a structure element of an onject or an array
   * with own label (a key of an object or an index of an array)
   * and value of any json data type. The root object or array
   * is a node without label.
   * {...
   * [+] "label": value,
   * ...}
   *
   * Markup:
   * <li class="jsontree_node [jsontree_node_expanded]">
   *     <span class="jsontree_label-wrapper">
   *         <span class="jsontree_label">
   *             <span class="jsontree_expand-button" />
   *             "label"
   *         </span>
   *         :
   *     </span>
   *     <(div|span) class="jsontree_value jsontree_value_(object|array|boolean|null|number|string)">
   *         ...
   *     </(div|span)>
   * </li>
   *
   * @param label {string} - key name
   * @param val {Object | Array | string | number | boolean | null} - a value of node
   * @param isLast {boolean} - true if node is last in list of siblings
   *
   * @return {Node}
   */
  function Node(label, val, isLast) {
    const nodeType = utils.getType(val);

    if (nodeType in Node.CONSTRUCTORS) {
      return new Node.CONSTRUCTORS[nodeType](label, val, isLast);
    }
    throw new Error(`Bad type: ${utils.getClass(val)}`);
  }

  Node.CONSTRUCTORS = {
    boolean: NodeBoolean,
    number: NodeNumber,
    string: NodeString,
    null: NodeNull,
    object: NodeObject,
    array: NodeArray,
  };


  /*
   * The constructor for simple types (string, number, boolean, null)
   * {...
   * [+] "label": value,
   * ...}
   * value = string || number || boolean || null
   *
   * Markup:
   * <li class="jsontree_node">
   *     <span class="jsontree_label-wrapper">
   *         <span class="jsontree_label">"age"</span>
   *         :
   *     </span>
   *     <span class="jsontree_value jsontree_value_(number|boolean|string|null)">25</span>
   *     ,
   * </li>
   *
   * @abstract
   * @param label {string} - key name
   * @param val {string | number | boolean | null} - a value of simple types
   * @param isLast {boolean} - true if node is last in list of parent childNodes
   */
  function _NodeSimple(label, val, isLast) {
    if (this.constructor === _NodeSimple) {
      throw new Error('This is abstract class');
    }

    const self = this;
    const el = document.createElement('li');
    let labelEl;
    const template = function (label, val) {
      const str = `\
                  <span class="jsontree_label-wrapper">\
                      <span class="jsontree_label">"${
  label
  }"</span> : \
                  </span>\
                  <span class="jsontree_value-wrapper">\
                      <span class="jsontree_value jsontree_value_${self.type}">${
    val
  }</span>${
    !isLast ? ',' : ''
  }</span>`;

      return str;
    };

    self.label = label;
    self.isComplex = false;

    el.classList.add('jsontree_node');
    el.innerHTML = template(label, val);

    self.el = el;

    labelEl = el.querySelector('.jsontree_label');

    labelEl.addEventListener('click', (e) => {
      if (e.altKey) {
        self.toggleMarked();
        return;
      }

      if (e.shiftKey) {
        document.getSelection().removeAllRanges();
        alert(self.getJSONPath());
      }
    }, false);
  }

  _NodeSimple.prototype = {
    constructor: _NodeSimple,

    /**
       * Mark node
       */
    mark() {
      this.el.classList.add('jsontree_node_marked');
    },

    /**
       * Unmark node
       */
    unmark() {
      this.el.classList.remove('jsontree_node_marked');
    },

    /**
       * Mark or unmark node
       */
    toggleMarked() {
      this.el.classList.toggle('jsontree_node_marked');
    },

    /**
       * Expands parent node of this node
       *
       * @param isRecursive {boolean} - if true, expands all parent nodes
       *                                (from node to root)
       */
    expandParent(isRecursive) {
      if (!this.parent) {
        return;
      }

      this.parent.expand();
      this.parent.expandParent(isRecursive);
    },

    /**
       * Returns JSON-path of this
       *
       * @param isInDotNotation {boolean} - kind of notation for returned json-path
       *                                    (by default, in bracket notation)
       * @returns {string}
       */
    getJSONPath(isInDotNotation) {
      if (this.isRoot) {
        return '$';
      }

      let currentPath;

      if (this.parent.type === 'array') {
        currentPath = `[${this.label}]`;
      } else {
        currentPath = isInDotNotation ? `.${this.label}` : `['${this.label}']`;
      }

      return this.parent.getJSONPath(isInDotNotation) + currentPath;
    },
  };


  /*
   * The constructor for boolean values
   * {...
   * [+] "label": boolean,
   * ...}
   * boolean = true || false
   *
   * @constructor
   * @param label {string} - key name
   * @param val {boolean} - value of boolean type, true or false
   * @param isLast {boolean} - true if node is last in list of parent childNodes
   */
  function NodeBoolean(label, val, isLast) {
    this.type = 'boolean';

    _NodeSimple.call(this, label, val, isLast);
  }
  utils.inherits(NodeBoolean, _NodeSimple);


  /*
   * The constructor for number values
   * {...
   * [+] "label": number,
   * ...}
   * number = 123
   *
   * @constructor
   * @param label {string} - key name
   * @param val {number} - value of number type, for example 123
   * @param isLast {boolean} - true if node is last in list of parent childNodes
   */
  function NodeNumber(label, val, isLast) {
    this.type = 'number';

    _NodeSimple.call(this, label, val, isLast);
  }
  utils.inherits(NodeNumber, _NodeSimple);


  /*
   * The constructor for string values
   * {...
   * [+] "label": string,
   * ...}
   * string = "abc"
   *
   * @constructor
   * @param label {string} - key name
   * @param val {string} - value of string type, for example "abc"
   * @param isLast {boolean} - true if node is last in list of parent childNodes
   */
  function NodeString(label, val, isLast) {
    this.type = 'string';

    _NodeSimple.call(this, label, `"${val}"`, isLast);
  }
  utils.inherits(NodeString, _NodeSimple);


  /*
   * The constructor for null values
   * {...
   * [+] "label": null,
   * ...}
   *
   * @constructor
   * @param label {string} - key name
   * @param val {null} - value (only null)
   * @param isLast {boolean} - true if node is last in list of parent childNodes
   */
  function NodeNull(label, val, isLast) {
    this.type = 'null';

    _NodeSimple.call(this, label, val, isLast);
  }
  utils.inherits(NodeNull, _NodeSimple);


  /*
   * The constructor for complex types (object, array)
   * {...
   * [+] "label": value,
   * ...}
   * value = object || array
   *
   * Markup:
   * <li class="jsontree_node jsontree_node_(object|array) [expanded]">
   *     <span class="jsontree_label-wrapper">
   *         <span class="jsontree_label">
   *             <span class="jsontree_expand-button" />
   *             "label"
   *         </span>
   *         :
   *     </span>
   *     <div class="jsontree_value">
   *         <b>{</b>
   *         <ul class="jsontree_child-nodes" />
   *         <b>}</b>
   *         ,
   *     </div>
   * </li>
   *
   * @abstract
   * @param label {string} - key name
   * @param val {Object | Array} - a value of complex types, object or array
   * @param isLast {boolean} - true if node is last in list of parent childNodes
   */
  function _NodeComplex(label, val, isLast) {
    if (this.constructor === _NodeComplex) {
      throw new Error('This is abstract class');
    }

    const self = this;
    const el = document.createElement('li');
    const template = function (label, sym) {
      const comma = (!isLast) ? ',' : '';
      let str = `\
                      <div class="jsontree_value-wrapper">\
                          <div class="jsontree_value jsontree_value_${self.type}">\
                              <b>${sym[0]}</b>\
                              <span class="jsontree_show-more">&hellip;</span>\
                              <ul class="jsontree_child-nodes"></ul>\
                              <b>${sym[1]}</b>`
                          + `</div>${comma
                          }</div>`;

      if (label !== null) {
        str = `${'\
                      <span class="jsontree_label-wrapper">\
                          <span class="jsontree_label">'
                              + '<span class="jsontree_expand-button"></span>'
                              + '"'}${label
        }"</span> : \
                      </span>${str}`;
      }

      return str;
    };
    let childNodesUl;
    let labelEl;
    let moreContentEl;
    const childNodes = [];

    self.label = label;
    self.isComplex = true;

    el.classList.add('jsontree_node');
    el.classList.add('jsontree_node_complex');
    el.innerHTML = template(label, self.sym);

    childNodesUl = el.querySelector('.jsontree_child-nodes');

    if (label !== null) {
      labelEl = el.querySelector('.jsontree_label');
      moreContentEl = el.querySelector('.jsontree_show-more');

      labelEl.addEventListener('click', (e) => {
        if (e.altKey) {
          self.toggleMarked();
          return;
        }

        if (e.shiftKey) {
          document.getSelection().removeAllRanges();
          alert(self.getJSONPath());
          return;
        }

        self.toggle(e.ctrlKey || e.metaKey);
      }, false);

      moreContentEl.addEventListener('click', (e) => {
        self.toggle(e.ctrlKey || e.metaKey);
      }, false);

      self.isRoot = false;
    } else {
      self.isRoot = true;
      self.parent = null;

      el.classList.add('jsontree_node_expanded');
    }

    self.el = el;
    self.childNodes = childNodes;
    self.childNodesUl = childNodesUl;

    utils.forEachNode(val, (label, node, isLast) => {
      self.addChild(new Node(label, node, isLast));
    });

    self.isEmpty = !childNodes.length;
    if (self.isEmpty) {
      el.classList.add('jsontree_node_empty');
    }
  }

  utils.inherits(_NodeComplex, _NodeSimple);

  utils.extend(_NodeComplex.prototype, {
    constructor: _NodeComplex,

    /*
       * Add child node to list of child nodes
       *
       * @param child {Node} - child node
       */
    addChild(child) {
      this.childNodes.push(child);
      this.childNodesUl.appendChild(child.el);
      child.parent = this;
    },

    /*
       * Expands this list of node child nodes
       *
       * @param isRecursive {boolean} - if true, expands all child nodes
       */
    expand(isRecursive) {
      if (this.isEmpty) {
        return;
      }

      if (!this.isRoot) {
        this.el.classList.add('jsontree_node_expanded');
      }

      if (isRecursive) {
        this.childNodes.forEach((item, i) => {
          if (item.isComplex) {
            item.expand(isRecursive);
          }
        });
      }
    },

    /*
       * Collapses this list of node child nodes
       *
       * @param isRecursive {boolean} - if true, collapses all child nodes
       */
    collapse(isRecursive) {
      if (this.isEmpty) {
        return;
      }

      if (!this.isRoot) {
        this.el.classList.remove('jsontree_node_expanded');
      }

      if (isRecursive) {
        this.childNodes.forEach((item, i) => {
          if (item.isComplex) {
            item.collapse(isRecursive);
          }
        });
      }
    },

    /*
       * Expands collapsed or collapses expanded node
       *
       * @param {boolean} isRecursive - Expand all child nodes if this node is expanded
       *                                and collapse it otherwise
       */
    toggle(isRecursive) {
      if (this.isEmpty) {
        return;
      }

      this.el.classList.toggle('jsontree_node_expanded');

      if (isRecursive) {
        const isExpanded = this.el.classList.contains('jsontree_node_expanded');

        this.childNodes.forEach((item, i) => {
          if (item.isComplex) {
            item[isExpanded ? 'expand' : 'collapse'](isRecursive);
          }
        });
      }
    },

    /**
       * Find child nodes that match some conditions and handle it
       *
       * @param {Function} matcher
       * @param {Function} handler
       * @param {boolean} isRecursive
       */
    findChildren(matcher, handler, isRecursive) {
      if (this.isEmpty) {
        return;
      }

      this.childNodes.forEach((item, i) => {
        if (matcher(item)) {
          handler(item);
        }

        if (item.isComplex && isRecursive) {
          item.findChildren(matcher, handler, isRecursive);
        }
      });
    },
  });


  /*
   * The constructor for object values
   * {...
   * [+] "label": object,
   * ...}
   * object = {"abc": "def"}
   *
   * @constructor
   * @param label {string} - key name
   * @param val {Object} - value of object type, {"abc": "def"}
   * @param isLast {boolean} - true if node is last in list of siblings
   */
  function NodeObject(label, val, isLast) {
    this.sym = ['{', '}'];
    this.type = 'object';

    _NodeComplex.call(this, label, val, isLast);
  }
  utils.inherits(NodeObject, _NodeComplex);


  /*
   * The constructor for array values
   * {...
   * [+] "label": array,
   * ...}
   * array = [1,2,3]
   *
   * @constructor
   * @param label {string} - key name
   * @param val {Array} - value of array type, [1,2,3]
   * @param isLast {boolean} - true if node is last in list of siblings
   */
  function NodeArray(label, val, isLast) {
    this.sym = ['[', ']'];
    this.type = 'array';

    _NodeComplex.call(this, label, val, isLast);
  }
  utils.inherits(NodeArray, _NodeComplex);


  /* ---------- The tree constructor ---------- */

  /*
   * The constructor for json tree.
   * It contains only one Node (Array or Object), without property name.
   * CSS-styles of .tree define main tree styles like font-family,
   * font-size and own margins.
   *
   * Markup:
   * <ul class="jsontree_tree clearfix">
   *     {Node}
   * </ul>
   *
   * @constructor
   * @param jsonObj {Object | Array} - data for tree
   * @param domEl {DOMElement} - DOM-element, wrapper for tree
   */
  function Tree(jsonObj, domEl) {
    this.wrapper = document.createElement('ul');
    this.wrapper.className = 'jsontree_tree clearfix';

    this.rootNode = null;

    this.sourceJSONObj = jsonObj;

    this.loadData(jsonObj);
    this.appendTo(domEl);
  }

  Tree.prototype = {
    constructor: Tree,

    /**
       * Fill new data in current json tree
       *
       * @param {Object | Array} jsonObj - json-data
       */
    loadData(jsonObj) {
      if (!utils.isValidRoot(jsonObj)) {
        alert('The root should be an object or an array');
        return;
      }

      this.sourceJSONObj = jsonObj;

      this.rootNode = new Node(null, jsonObj, 'last');
      this.wrapper.innerHTML = '';
      this.wrapper.appendChild(this.rootNode.el);
    },

    /**
       * Appends tree to DOM-element (or move it to new place)
       *
       * @param {DOMElement} domEl
       */
    appendTo(domEl) {
      domEl.appendChild(this.wrapper);
    },

    /**
       * Expands all tree nodes (objects or arrays) recursively
       *
       * @param {Function} filterFunc - 'true' if this node should be expanded
       */
    expand(filterFunc) {
      if (this.rootNode.isComplex) {
        if (typeof filterFunc === 'function') {
          this.rootNode.childNodes.forEach((item, i) => {
            if (item.isComplex && filterFunc(item)) {
              item.expand();
            }
          });
        } else {
          this.rootNode.expand('recursive');
        }
      }
    },

    /**
       * Collapses all tree nodes (objects or arrays) recursively
       */
    collapse() {
      if (typeof this.rootNode.collapse === 'function') {
        this.rootNode.collapse('recursive');
      }
    },

    /**
       * Returns the source json-string (pretty-printed)
       *
       * @param {boolean} isPrettyPrinted - 'true' for pretty-printed string
       * @returns {string} - for exemple, '{"a":2,"b":3}'
       */
    toSourceJSON(isPrettyPrinted) {
      if (!isPrettyPrinted) {
        return JSON.stringify(this.sourceJSONObj);
      }

      const DELIMETER = '[%^$#$%^%]';
      let jsonStr = JSON.stringify(this.sourceJSONObj, null, DELIMETER);

      jsonStr = jsonStr.split('\n').join('<br />');
      jsonStr = jsonStr.split(DELIMETER).join('&nbsp;&nbsp;&nbsp;&nbsp;');

      return jsonStr;
    },

    /**
       * Find all nodes that match some conditions and handle it
       */
    findAndHandle(matcher, handler) {
      this.rootNode.findChildren(matcher, handler, 'isRecursive');
    },

    /**
       * Unmark all nodes
       */
    unmarkAll() {
      this.rootNode.findChildren((node) => true, (node) => {
        node.unmark();
      }, 'isRecursive');
    },
  };


  /* ---------- Public methods ---------- */
  return {
    /**
       * Creates new tree by data and appends it to the DOM-element
       *
       * @param jsonObj {Object | Array} - json-data
       * @param domEl {DOMElement} - the wrapper element
       * @returns {Tree}
       */
    create(jsonObj, domEl) {
      return new Tree(jsonObj, domEl);
    },
  };
}());
